Laravel Database——Eloquent Model 更新关联模型

前言

在前两篇文章中,向大家介绍了定义关联关系的源码,还有基于关联关系的关联模型加载与查询的源码分析,本文开始介绍第三部分,如何利用关联关系来更新插入关联模型。

hasOne/hasMany/MorphOne/MorphMany 更新与插入

save 方法

正向的一对一、一对多关联保存方法用于对子模型设置外键值:

  1. public function save(Model $model)
  2. {
  3. $this->setForeignAttributesForCreate($model);
  4. return $model->save() ? $model : false;
  5. }
  6. protected function setForeignAttributesForCreate(Model $model)
  7. {
  8. $model->setAttribute($this->getForeignKeyName(), $this->getParentKey());
  9. }
  10. public function getParentKey()
  11. {
  12. return $this->parent->getAttribute($this->localKey);
  13. }

saveMany 方法

  1. public function saveMany($models)
  2. {
  3. foreach ($models as $model) {
  4. $this->save($model);
  5. }
  6. return $models;
  7. }

create 方法

create 方法与 save 方法功能一致,唯一不同的是 create 的参数是属性,save 方法的参数是 model

  1. public function create(array $attributes = [])
  2. {
  3. return tap($this->related->newInstance($attributes), function ($instance) {
  4. $this->setForeignAttributesForCreate($instance);
  5. $instance->save();
  6. });
  7. }
  8. protected function setForeignAttributesForCreate(Model $model)
  9. {
  10. $model->setAttribute($this->getForeignKeyName(), $this->getParentKey());
  11. }

createMany 方法

  1. public function createMany(array $records)
  2. {
  3. $instances = $this->related->newCollection();
  4. foreach ($records as $record) {
  5. $instances->push($this->create($record));
  6. }
  7. return $instances;
  8. }

make 方法

make 方法用于建立子模型对象,但是并不进行保存操作:

  1. public function make(array $attributes = [])
  2. {
  3. return tap($this->related->newInstance($attributes), function ($instance) {
  4. $this->setForeignAttributesForCreate($instance);
  5. });
  6. }

update 方法

update 方法用于更新子模型的属性,值得注意的是时间戳的更新:

  1. public function update(array $attributes)
  2. {
  3. if ($this->related->usesTimestamps()) {
  4. $attributes[$this->relatedUpdatedAt()] = $this->related->freshTimestampString();
  5. }
  6. return $this->query->update($attributes);
  7. }

findOrNew 方法

  1. public function findOrNew($id, $columns = ['*'])
  2. {
  3. if (is_null($instance = $this->find($id, $columns))) {
  4. $instance = $this->related->newInstance();
  5. $this->setForeignAttributesForCreate($instance);
  6. }
  7. return $instance;
  8. }

firstOrCreate 方法

实际调用的是 create 方法:

  1. public function firstOrCreate(array $attributes, array $values = [])
  2. {
  3. if (is_null($instance = $this->where($attributes)->first())) {
  4. $instance = $this->create($attributes + $values);
  5. }
  6. return $instance;
  7. }

updateOrCreate 方法

  1. public function updateOrCreate(array $attributes, array $values = [])
  2. {
  3. return tap($this->firstOrNew($attributes), function ($instance) use ($values) {
  4. $instance->fill($values);
  5. $instance->save();
  6. });
  7. }

belongsTo/MorphTo 更新

save 方法

如果我们在子模型加一个包含关联名称的 touches 属性后,当我们更新一个子模型时,对应父模型的 updated_at 字段也会被同时更新:

  1. class Comment extends Model
  2. {
  3. protected $touches = ['post'];
  4. public function post()
  5. {
  6. return $this->belongsTo('App\Post');
  7. }
  8. }
  9. $comment = App\Comment::find(1);
  10. $comment->text = '编辑了这条评论!';
  11. $comment->save();

这是由于,对子模型调用 save 方法会引发 finishSave 函数:

  1. protected function finishSave(array $options)
  2. {
  3. $this->fireModelEvent('saved', false);
  4. if ($this->isDirty() && ($options['touch'] ?? true)) {
  5. $this->touchOwners();
  6. }
  7. $this->syncOriginal();
  8. }

可以看到,touchOwners 函数被调用:

  1. public function touchOwners()
  2. {
  3. foreach ($this->touches as $relation) {
  4. $this->$relation()->touch();
  5. if ($this->$relation instanceof self) {
  6. $this->$relation->fireModelEvent('saved', false);
  7. $this->$relation->touchOwners();
  8. } elseif ($this->$relation instanceof Collection) {
  9. $this->$relation->each(function (Model $relation) {
  10. $relation->touchOwners();
  11. });
  12. }
  13. }
  14. }

可以看到,touchOwners 函数会调用 touch 函数,该函数用于更新父模型的时间戳:

  1. public function touch()
  2. {
  3. $column = $this->getRelated()->getUpdatedAtColumn();
  4. $this->rawUpdate([$column => $this->getRelated()->freshTimestampString()]);
  5. }

之后,父模型还会递归调用 touchOwners 函数,不断更新上一级的父模型。

update 方法

belongsTo/MorphTo 的更新方法用于父模型的属性更新:

  1. public function update(array $attributes)
  2. {
  3. return $this->getResults()->fill($attributes)->save();
  4. }

associate 方法

如果想要更新 belongsTo 关联时,可以使用 associate 方法。此方法会在子模型中设置外键:

  1. public function associate($model)
  2. {
  3. $ownerKey = $model instanceof Model ? $model->getAttribute($this->ownerKey) : $model;
  4. $this->child->setAttribute($this->foreignKey, $ownerKey);
  5. if ($model instanceof Model) {
  6. $this->child->setRelation($this->relation, $model);
  7. }
  8. return $this->child;
  9. }

dissociate 方法

当删除 belongsTo 关联时,可以使用 dissociate方法。此方法会设置关联外键为 null:

  1. public function dissociate()
  2. {
  3. $this->child->setAttribute($this->foreignKey, null);
  4. return $this->child->setRelation($this->relation, null);
  5. }

belongsToMany/MorphToMany/MorphByMany 更新与插入

attach 方法

attach 方法用于为多对多关系添加新的关联关系,主要进行了中间表的插入工作,用法:

  1. $user = App\User::find(1);
  2. $user->roles()->attach($roleId);
  3. //也可以通过传递一个数组参数向中间表写入额外数据
  4. $user->roles()->attach($roleId, ['expires' => $expires]);
  5. //为了方便,还允许传入 ID 数组:
  6. $user->roles()->attach([
  7. 1 => ['expires' => $expires],
  8. 2 => ['expires' => $expires]
  9. ]);

源码:

  1. public function attach($id, array $attributes = [], $touch = true)
  2. {
  3. $this->newPivotStatement()->insert($this->formatAttachRecords(
  4. $this->parseIds($id), $attributes
  5. ));
  6. if ($touch) {
  7. $this->touchIfTouching();
  8. }
  9. }
  10. protected function parseIds($value)
  11. {
  12. if ($value instanceof Model) {
  13. return [$value->getKey()];
  14. }
  15. if ($value instanceof Collection) {
  16. return $value->modelKeys();
  17. }
  18. if ($value instanceof BaseCollection) {
  19. return $value->toArray();
  20. }
  21. return (array) $value;
  22. }
  23. public function newPivotStatement()
  24. {
  25. return $this->query->getQuery()->newQuery()->from($this->table);
  26. }

可以看到,attach 函数最重要的是对中间表插入新数据。

在说这段代码之前,我们要先说说多对多关联关系独有的设置:

中间表 Pivot 特殊初始化设置

  • 自定义中间表模型
  1. class Role extends Model
  2. {
  3. /**
  4. * 获得此角色下的用户。
  5. */
  6. public function users()
  7. {
  8. return $this->belongsToMany('App\User')->using('App\UserRole');
  9. }
  10. }

using 源码非常简单:

  1. public function using($class)
  2. {
  3. $this->using = $class;
  4. return $this;
  5. }
  • 中间表时间戳字段
  1. return $this->belongsToMany('App\Role')->withTimestamps();

withTimestamps 源码:

  1. public function withTimestamps($createdAt = null, $updatedAt = null)
  2. {
  3. $this->pivotCreatedAt = $createdAt;
  4. $this->pivotUpdatedAt = $updatedAt;
  5. return $this->withPivot($this->createdAt(), $this->updatedAt());
  6. }
  7. public function createdAt()
  8. {
  9. return $this->pivotCreatedAt ?: $this->parent->getCreatedAtColumn();
  10. }
  11. public function updatedAt()
  12. {
  13. return $this->pivotUpdatedAt ?: $this->parent->getUpdatedAtColumn();
  14. }
  • 中间表自定义字段
  1. return $this->belongsToMany('App\Role')->withPivot('column1', 'column2');

自定义字段都会存放在 pivotColumns 中:

  1. public function withPivot($columns)
  2. {
  3. $this->pivotColumns = array_merge(
  4. $this->pivotColumns, is_array($columns) ? $columns : func_get_args()
  5. );
  6. return $this;
  7. }

中间表时间戳

我们接着说中间表的插入代码:

  1. protected function formatAttachRecords($ids, array $attributes)
  2. {
  3. $records = [];
  4. $hasTimestamps = ($this->hasPivotColumn($this->createdAt()) ||
  5. $this->hasPivotColumn($this->updatedAt()));
  6. $attributes = $this->using
  7. ? $this->newPivot()->forceFill($attributes)->getAttributes()
  8. : $attributes;
  9. foreach ($ids as $key => $value) {
  10. $records[] = $this->formatAttachRecord(
  11. $key, $value, $attributes, $hasTimestamps
  12. );
  13. }
  14. return $records;
  15. }

如果我们在设置多对多关联关系的时候,使用了时间戳,那么 hasTimestamps 就会为 true

初始化 Pivot

当我们设置了自定义的中间表模型时,就会调用 newPivot 函数:

  1. public function newPivot(array $attributes = [], $exists = false)
  2. {
  3. $pivot = $this->related->newPivot(
  4. $this->parent, $attributes, $this->table, $exists, $this->using
  5. );
  6. return $pivot->setPivotKeys($this->foreignPivotKey, $this->relatedPivotKey);
  7. }
  8. public function newPivot(Model $parent, array $attributes, $table, $exists, $using = null)
  9. {
  10. return $using ? $using::fromRawAttributes($parent, $attributes, $table, $exists)
  11. : Pivot::fromAttributes($parent, $attributes, $table, $exists);
  12. }
  13. public function setPivotKeys($foreignKey, $relatedKey)
  14. {
  15. $this->foreignKey = $foreignKey;
  16. $this->relatedKey = $relatedKey;
  17. return $this;
  18. }

可以看到,newPivot 会返回 Pivot 类型的对象,另外为中间表设置了 foreignKeyrelatedKey

生成 insert 数组

  1. protected function formatAttachRecord($key, $value, $attributes, $hasTimestamps)
  2. {
  3. list($id, $attributes) = $this->extractAttachIdAndAttributes($key, $value, $attributes);
  4. return array_merge(
  5. $this->baseAttachRecord($id, $hasTimestamps), $attributes
  6. );
  7. }
  8. protected function extractAttachIdAndAttributes($key, $value, array $attributes)
  9. {
  10. return is_array($value)
  11. ? [$key, array_merge($value, $attributes)]
  12. : [$value, $attributes];
  13. }

extractAttachIdAndAttributes 用于获得插入记录的主键 id,与其对应的属性。由于可以这样进行传入参数:

  1. $user->roles()->attach([
  2. 1 => ['expires' => $expires],
  3. 2 => ['expires' => $expires]
  4. ]);

所以要判断一下 value 是否是数组。baseAttachRecord 最终生成用于 insert 的属性数组:

  1. protected function baseAttachRecord($id, $timed)
  2. {
  3. $record[$this->relatedPivotKey] = $id;
  4. $record[$this->foreignPivotKey] = $this->parent->{$this->parentKey};
  5. if ($timed) {
  6. $record = $this->addTimestampsToAttachment($record);
  7. }
  8. return $record;
  9. }
  10. protected function addTimestampsToAttachment(array $record, $exists = false)
  11. {
  12. $fresh = $this->parent->freshTimestamp();
  13. if (! $exists && $this->hasPivotColumn($this->createdAt())) {
  14. $record[$this->createdAt()] = $fresh;
  15. }
  16. if ($this->hasPivotColumn($this->updatedAt())) {
  17. $record[$this->updatedAt()] = $fresh;
  18. }
  19. return $record;
  20. }

touchIfTouching 更新多对多时间戳更新

对中间表进行插入操作后,就要对父模型与 related 模型进行时间戳更新操作:

  1. public function touchIfTouching()
  2. {
  3. if ($this->touchingParent()) {
  4. $this->getParent()->touch();
  5. }
  6. if ($this->getParent()->touches($this->relationName)) {
  7. $this->touch();
  8. }
  9. }
  10. public function touch()
  11. {
  12. if (! $this->usesTimestamps()) {
  13. return false;
  14. }
  15. $this->updateTimestamps();
  16. return $this->save();
  17. }

首先,如果 related 模型的 touchs 数组中有本多对多关系,那么父模型就要进行时间戳更新操作:

  1. protected function touchingParent()
  2. {
  3. return $this->getRelated()->touches($this->guessInverseRelation());
  4. }
  5. protected function guessInverseRelation()
  6. {
  7. return Str::camel(Str::plural(class_basename($this->getParent())));
  8. }

其次,如果父模型的 touchs 数组中存在多对多关联,那么就要进行多对多关联的 touch 函数,对 related 模型进行时间戳更新操作:

  1. public function touch()
  2. {
  3. $key = $this->getRelated()->getKeyName();
  4. $columns = [
  5. $this->related->getUpdatedAtColumn() => $this->related->freshTimestampString(),
  6. ];
  7. if (count($ids = $this->allRelatedIds()) > 0) {
  8. $this->getRelated()->newQuery()->whereIn($key, $ids)->update($columns);
  9. }
  10. }
  11. public function allRelatedIds()
  12. {
  13. return $this->newPivotQuery()->pluck($this->relatedPivotKey);
  14. }

save 方法

belongsToManysave 方法用于更新多对多关系,该函数会:

  • 更新 related 模型属性
  • 在中间表中添加新的记录
  • 更新父模型与 related 模型的时间戳

主要调用了 attach 函数:

  1. public function save(Model $model, array $pivotAttributes = [], $touch = true)
  2. {
  3. $model->save(['touch' => false]);
  4. $this->attach($model->getKey(), $pivotAttributes, $touch);
  5. return $model;
  6. }

saveMany 方法

  1. public function saveMany($models, array $pivotAttributes = [])
  2. {
  3. foreach ($models as $key => $model) {
  4. $this->save($model, (array) ($pivotAttributes[$key] ?? []), false);
  5. }
  6. $this->touchIfTouching();
  7. return $models;
  8. }

create 方法

多对多的 create 方法用于保存 related 的属性,并且可以为中间表添加 joining 属性信息:

  1. public function create(array $attributes = [], array $joining = [], $touch = true)
  2. {
  3. $instance = $this->related->newInstance($attributes);
  4. $instance->save(['touch' => false]);
  5. $this->attach($instance->getKey(), $joining, $touch);
  6. return $instance;
  7. }

createMany 方法

  1. public function createMany(array $records, array $joinings = [])
  2. {
  3. $instances = [];
  4. foreach ($records as $key => $record) {
  5. $instances[] = $this->create($record, (array) ($joinings[$key] ?? []), false);
  6. }
  7. $this->touchIfTouching();
  8. return $instances;
  9. }

detach 方法

detach 方法比较简单,重要的是对中间表进行删除操作:

  1. public function detach($ids = null, $touch = true)
  2. {
  3. $query = $this->newPivotQuery();
  4. if (! is_null($ids)) {
  5. $ids = $this->parseIds($ids);
  6. if (empty($ids)) {
  7. return 0;
  8. }
  9. $query->whereIn($this->relatedPivotKey, (array) $ids);
  10. }
  11. $results = $query->delete();
  12. if ($touch) {
  13. $this->touchIfTouching();
  14. }
  15. return $results;
  16. }

同步关联 sync

  1. $user->roles()->sync([1, 2, 3]);
  2. //可以通过 ID 传递其他额外的数据到中间表:
  3. $user->roles()->sync([1 => ['expires' => true], 2, 3]);

源码:

  1. public function sync($ids, $detaching = true)
  2. {
  3. $changes = [
  4. 'attached' => [], 'detached' => [], 'updated' => [],
  5. ];
  6. $current = $this->newPivotQuery()->pluck(
  7. $this->relatedPivotKey
  8. )->all();
  9. $detach = array_diff($current, array_keys(
  10. $records = $this->formatRecordsList($this->parseIds($ids))
  11. ));
  12. if ($detaching && count($detach) > 0) {
  13. $this->detach($detach);
  14. $changes['detached'] = $this->castKeys($detach);
  15. }
  16. $changes = array_merge(
  17. $changes, $this->attachNew($records, $current, false)
  18. );
  19. if (count($changes['attached']) ||
  20. count($changes['updated'])) {
  21. $this->touchIfTouching();
  22. }
  23. return $changes;
  24. }

同步关联需要删除未出现的 id,更新已经存在 id,增添新出现的 id

  1. $current = $this->newPivotQuery()->pluck(
  2. $this->relatedPivotKey
  3. )->all();

这句用于从中间表中取出所有关联的中间表记录,并且取出 relatedPivotKey 值。

  1. $detach = array_diff($current, array_keys(
  2. $records = $this->formatRecordsList($this->parseIds($ids))
  3. ));
  4. protected function formatRecordsList(array $records)
  5. {
  6. return collect($records)->mapWithKeys(function ($attributes, $id) {
  7. if (! is_array($attributes)) {
  8. list($id, $attributes) = [$attributes, []];
  9. }
  10. return [$id => $attributes];
  11. })->all();
  12. }

这句用于统计出待删除的中间表记录的 relatedPivotKey 值。

  1. if ($detaching && count($detach) > 0) {
  2. $this->detach($detach);
  3. $changes['detached'] = $this->castKeys($detach);
  4. }

这句进行删除操作。

  1. $changes = array_merge(
  2. $changes, $this->attachNew($records, $current, false)
  3. );
  4. protected function attachNew(array $records, array $current, $touch = true)
  5. {
  6. $changes = ['attached' => [], 'updated' => []];
  7. foreach ($records as $id => $attributes) {
  8. if (! in_array($id, $current)) {
  9. $this->attach($id, $attributes, $touch);
  10. $changes['attached'][] = $this->castKey($id);
  11. }
  12. elseif (count($attributes) > 0 &&
  13. $this->updateExistingPivot($id, $attributes, $touch)) {
  14. $changes['updated'][] = $this->castKey($id);
  15. }
  16. }
  17. return $changes;
  18. }

对于需要新增的记录,直接调用方法 attach 即可。对于需要更新的记录,需要调用 updateExistingPivot :

  1. public function updateExistingPivot($id, array $attributes, $touch = true)
  2. {
  3. if (in_array($this->updatedAt(), $this->pivotColumns)) {
  4. $attributes = $this->addTimestampsToAttachment($attributes, true);
  5. }
  6. $updated = $this->newPivotStatementForId($id)->update($attributes);
  7. if ($touch) {
  8. $this->touchIfTouching();
  9. }
  10. return $updated;
  11. }
  12. public function newPivotStatementForId($id)
  13. {
  14. return $this->newPivotQuery()->where($this->relatedPivotKey, $id);
  15. }

这个函数主要调用 update 方法。

切换关联 toggle

多对多关联也提供了一个 toggle 方法用于「切换」给定 IDs 的附加状态。如果给定 ID 已附加,就会被移除。同样的,如果给定 ID 已移除,就会被附加,源码:

  1. public function toggle($ids, $touch = true)
  2. {
  3. $changes = [
  4. 'attached' => [], 'detached' => [],
  5. ];
  6. $records = $this->formatRecordsList($this->parseIds($ids));
  7. $detach = array_values(array_intersect(
  8. $this->newPivotQuery()->pluck($this->relatedPivotKey)->all(),
  9. array_keys($records)
  10. ));
  11. if (count($detach) > 0) {
  12. $this->detach($detach, false);
  13. $changes['detached'] = $this->castKeys($detach);
  14. }
  15. $attach = array_diff_key($records, array_flip($detach));
  16. if (count($attach) > 0) {
  17. $this->attach($attach, [], false);
  18. $changes['attached'] = array_keys($attach);
  19. }
  20. if ($touch && (count($changes['attached']) ||
  21. count($changes['detached']))) {
  22. $this->touchIfTouching();
  23. }
  24. return $changes;
  25. }

toggle 函数先 intersect 被关联的主键,进行 detach 所有已经存在的记录,再 diff 被关联的主键,对其进行 attach 所有记录。

findOrNew 方法

findOrNew 函数用于 related 模型的主键搜索与新建:

  1. public function findOrNew($id, $columns = ['*'])
  2. {
  3. if (is_null($instance = $this->find($id, $columns))) {
  4. $instance = $this->related->newInstance();
  5. }
  6. return $instance;
  7. }

firstOrNew 方法

firstOrNew 函数用于 related 模型的属性搜索与新建:

  1. public function firstOrNew(array $attributes)
  2. {
  3. if (is_null($instance = $this->where($attributes)->first())) {
  4. $instance = $this->related->newInstance($attributes);
  5. }
  6. return $instance;
  7. }

firstOrCreate 方法

firstOrCreate 函数用于 related 模型的属性搜索与保存,attributesrelated 模型的搜索属性或保存属性,joining 是中间表属性:

  1. public function firstOrCreate(array $attributes, array $joining = [], $touch = true)
  2. {
  3. if (is_null($instance = $this->where($attributes)->first())) {
  4. $instance = $this->create($attributes, $joining, $touch);
  5. }
  6. return $instance;
  7. }

updateOrCreate 方法

updateOrCreate 函数用于 related 模型的更新,attributesrelated 模型的搜索属性,valuesrelated 模型的更新属性,joining 是中间表属性:

  1. public function updateOrCreate(array $attributes, array $values = [], array $joining = [], $touch = true)
  2. {
  3. if (is_null($instance = $this->where($attributes)->first())) {
  4. return $this->create($values, $joining, $touch);
  5. }
  6. $instance->fill($values);
  7. $instance->save(['touch' => false]);
  8. return $instance;
  9. }